iT邦幫忙

0

前後端各開 feature 分支時,前端怎麼拿到對的 API 型別

  • 分享至 

  • xImage
  •  

英文版在 dev.to。

我們前端的 API 型別都是從後端的 OpenAPI(Swagger)規格自動產生的,用的是 swagger-typescript-api。一開始只有一個專案時很單純,寫一支 script 去抓規格、產生檔案就好。但後來前端專案越來越多,每個專案都有一份差不多但又有點不一樣的 script,哪一份改了、哪一份沒改,很難追。

而真正麻煩的是分支。後端開發新功能時會開一個 feature 分支,這時候前端需要的是「後端這個 feature 分支」的型別,而不是 dev 的。這就帶出兩個問題:第一,前端要怎麼知道現在該抓哪個分支的規格?第二,feature 分支跟 dev 之間本來就有落差。

第二個問題比較隱蔽。後端的 feature 分支切出來之後,不會隨時 rebase dev,所以這段期間其他 feature 合進 dev 的 API,會包含後端該分支的 API,但你的 feature 分支的規格裡都沒有。而前端這邊會比較常 rebase dev,一 rebase,就把其他功能已經在用的 API 呼叫一起帶進來了。結果前端的程式碼用到了這些 API,型別卻是從後端 feature 分支的規格產生的,裡面根本沒有這些型別,typecheck 就直接失敗。明明自己什麼都沒改壞,CI 卻是紅的。

backend  dev           ──o──────────●──────────────   ● 其他分支合入 API X
backend  ft-coupon-v2     └──o──────────o──────────   沒有 rebase,規格裡沒有 API X

frontend dev           ──o──────────────●──────────   ● 其他功能已經在呼叫 API X
frontend ft-coupon-v2     └──o─────────────●───────   rebase dev,帶進 API X 的呼叫
                                           │
                型別用後端 ft-coupon-v2 的規格產生
                → 找不到 API X → typecheck 失敗

所以我做了一個內部的 CLI 套件,把「抓哪個分支的規格、怎麼合併、產生到哪裡」這件事統一起來。目前有 9 個前端專案在用,後來也有好幾位同事一起補了功能、修了 bug。這篇文章主要就是整理一下這個工具的做法,還有合併兩份 OpenAPI 規格時踩到的坑。後面這部分不管你用什麼工具產生型別,應該都用得到。

核心想法:前後端開同名分支

第一個問題,我的解法很簡單:前端的分支名稱跟後端一樣。 後端開 ft-coupon-v2,前端也開 ft-coupon-v2。

這樣「前端現在該用哪一份規格」就不用另外設定,看自己目前在哪個分支就知道了。切分支時型別跟著切,CI 跑的時候也是同一套邏輯。前後端的分支一對一對起來,討論的時候也很清楚:講到哪個分支,兩邊指的就是同一個功能。

但光有命名規則還不夠,前端還要知道「後端到底有沒有這個分支的規格」。所以我請後端開了一支 API,列出目前有部署規格的分支。前端產生型別前先打這支 API,拿自己的分支名稱去比對:

  • 有對到:抓這個分支的規格,再跟 dev 的合併(後面會講怎麼合)
  • 沒對到:代表這個分支只有前端在改、後端沒動,就退回用 staging 的規格
目前分支名稱 ──> 後端分支列表 API ──> 有同名分支?
                                       │ 有   ──> dev + 該分支規格合併 ──┐
                                       │ 沒有 ──> 退回 staging 規格 ─────┴──> 產生型別

這樣就沒有人需要記得「這次要把設定改成指到哪個分支」。不然很容易發生忘了改回來,拿 feature 分支的型別去 build 正式版;或是後端分支已經合併刪掉了,前端還在抓一個不存在的規格。現在這些都交給比對結果決定。

同樣的對應關係也用在執行階段。後端每個 feature 分支都會部署成一個獨立的 endpoint,所以前端在哪個分支,預設就打同名分支的 endpoint,型別跟實際呼叫的後端是同一份。如果想打別的後端(例如前端在 feature 分支上,但想接 staging 的資料測試),就用環境變數覆蓋:有設定就用設定的,沒設定就靜默使用 feature 分支的 endpoint。平常什麼都不用設,只有特殊情況才需要手動介入。

基本做法:一份設定檔、一個指令

每個專案只要在根目錄放一份設定檔,寫清楚要產生哪幾份文件、放在哪裡:

import { genConfig, API_DOC_NAME } from '@team/fe-manifest';

export default genConfig({
  outputDir: 'src/swagger',
  docs: [
    { doc: API_DOC_NAME.Public_API, outputFileName: 'public.api.ts' },
    { doc: API_DOC_NAME.Member_API, outputFileName: 'member.api.ts' },
  ],
});

接著跑 npx hc-manifest 就會產生型別化的 API client。用 genConfig 包一層,是為了讓設定檔本身也有型別檢查,打錯文件名稱在編輯器裡就會看到。

至於「目前的分支名稱」怎麼取得,工具會依這個順序找:

  1. --branch 參數(手動指定)
  2. 目前的 git 分支
  3. CI 的環境變數:GITHUB_HEAD_REF、GITHUB_REF_NAME、EAS Build 的 EAS_BUILD_GIT_COMMIT_BRANCH 等,取第一個有值的

這裡有個小細節:CI 常常是用 commit SHA checkout,這時候 git rev-parse --abbrev-ref HEAD 只會回傳 HEAD。所以拿到 HEAD 時要當成沒有結果,繼續往下找環境變數,不然就會去抓一個叫 HEAD 的分支。

拿到分支名稱後,就去跟後端的分支列表比對,對不到就退回 staging。另外,產生出來的檔案開頭會記下「從哪個分支產生的」和規格的更新時間。下次再跑時,如果分支跟時間都沒變,就直接跳過,不用每次重新產生。

合併兩份規格:不要直接 deep merge

回到開頭說的問題。如果目前在 feature 分支(我們的命名是 ft- 開頭),工具會同時抓 dev 跟 feature 分支的規格,用 dev 當底、feature 分支蓋在上面。這樣 feature 分支新增或修改的 API 會生效,而 dev 上別人新加的 API 也不會丟。

一開始我用的是 lodash.merge,看起來很合理,但實際上它合併陣列的方式是「照 index 一格一格蓋」。而 OpenAPI 裡很多東西都是陣列,這個行為就出了兩個很隱蔽的問題。這兩個問題都是同事在實際專案裡抓到、修掉的。

問題一:參數被錯誤合併。 假設 dev 的某個 API 有 4 個參數,第 4 個是一個 header;而 feature 分支在同一個位置放了一個新的 boolean query 參數。照 index 合併的話,這兩個不相干的參數就會被揉成一個,結果那個 boolean 參數的型別變成了 string。

解法是用參數的身分(name + in)來合併,而不是用位置:

const mergeParamsByIdentity = (devParams: Param[], ftParams: Param[]) => {
  const key = (p: Param) => `${p.name}::${p.in}`;
  const ftKeys = new Set(ftParams.map(key));
  // feature 分支的參數優先,dev 才有的參數接在後面
  return [...ftParams, ...devParams.filter((p) => !ftKeys.has(key(p)))];
};

問題二:enum 少了一個值。 dev 的某個 enum 有 9 個值,而 feature 分支是比較早切出來的,中間少了一個,只有 8 個。照 index 合併的話,後面的值全部往前移一格,最後一個又重複一次,產生出來的 enum 就靜靜地少了一個值,前端型別檢查直接壞掉,而且沒有任何警告。換另一種順序,甚至會拼出兩個分支都沒有宣告過的組合。required 也有一樣的問題。

解法是改用 lodash.mergeWith,自己決定陣列怎麼合:

const mergeArrayCustomizer = (devValue: unknown, ftValue: unknown) => {
  if (!Array.isArray(devValue) || !Array.isArray(ftValue)) return undefined;

  // enum、required 這種純值的陣列:取聯集,哪一邊宣告的值都不會丟
  if (isPureValueArray(devValue) && isPureValueArray(ftValue)) {
    return [...new Set([...devValue, ...ftValue])];
  }
  // 物件或 $ref 的陣列:整個用 feature 分支的,不要照 index 揉
  return ftValue;
};

const merged = mergeWith({}, devSpec, ftSpec, mergeArrayCustomizer);
// 之後再把每個 operation 的 parameters 用 mergeParamsByIdentity 重合一次

但取聯集有個副作用:如果 feature 分支是「故意」拿掉某個 enum 值,聯集會把它加回來。這種情況工具沒辦法分辨是「還沒同步到」還是「故意移除」,所以我加了一個警告:只要聯集留下了 feature 分支沒有的值,就印出是哪個路徑、哪幾個值,讓人自己判斷。至少從「靜靜地錯」變成「看得到的取捨」。

另外還有一個邊界情況:如果 feature 分支明確寫了 parameters: [],而不是直接省略,那個「物件陣列整個用 feature 分支的」規則就會把 dev 的參數全部清掉。所以重合參數那一步,只要有一邊有參數就要重建,不能兩邊都有才做。

其他踩過的坑

operationId 被自動加上後綴。 後端的 Swagger 產生器為了避免 operationId 重複,會在重複的名字後面加上 _1、_2。但它是整份規格一起檢查的,所以就算兩個 API 分屬不同的 tag,名字一樣也會被加後綴。而 swagger-typescript-api 是依 tag 分成不同的 class,結果前端就要寫 cancelSubmission1(),而不是 cancelSubmission()。

所以產生型別前我會先把後綴拿掉,但只有在「同一個 tag 裡沒有別人叫這個名字」的時候才拿,不然同一個 class 裡就真的會有兩個同名的方法:

const match = op.operationId.match(/^(.+)_\d+$/);
if (match) {
  const base = match[1];
  const sameTagConflict = allOps.some(
    (other) => other.operationId === base && other.tags.some((t) => op.tags.includes(t)),
  );
  if (!sameTagConflict) op.operationId = base;
}

null 跟 undefined。 後端沒有值的欄位會回傳 null,所以產生出來的型別很多都是 T | null。但前端的程式碼大多是用 optional(T | undefined)在處理,兩邊一直對不起來。這部分我們做了兩層:

  • 執行階段:提供 axios 的 response interceptor,把回傳資料裡的 null 拿掉或轉成 undefined,同時把每個 API 的回傳型別也一起改掉。
  • 產生階段:另一位同事加了一個設定,讓產生出來的型別直接就是 T | undefined。

這兩層一定要搭配使用:型別寫 undefined,執行時卻還是拿到 null 的話,反而更危險。所以後來工具在產生型別時,也會順便檢查專案有沒有把 interceptor 接上。

只有時間戳記變了,就重寫整個檔案。 產生的檔案開頭有記錄更新時間,所以就算內容完全沒變,只要規格重新部署過,檔案就會被重寫,git diff 裡就多了一堆沒意義的變動。後來改成先忽略時間戳記比對內容,真的有變才寫入。

結果與心得

現在 9 個前端專案都用同一個指令產生型別,包括 Next.js 的網站跟 React Native 的 App。本機會根據目前的分支自動抓對的規格,CI 也是同一個指令,只要後端的 API 跟前端的用法對不上,型別檢查就會先擋下來,不用等到上線才發現。

整理一下這次學到的幾件事:

  • 用命名慣例取代設定。 前後端開同名分支,再加一支列出分支的 API,「該抓哪份規格」就變成自動判斷,不用靠人記得改設定。
  • 不要用通用的 deep merge 合併 OpenAPI 規格。 陣列照 index 合併在一般的設定檔裡沒什麼問題,但在 schema 裡會造成很隱蔽的錯誤。參數要用 name + in 合併,enum 和 required 要取聯集。
  • 靜默的錯誤比報錯還糟。 合併錯了不會報錯,只會產出一個「看起來很合理」的型別。所以有取捨的地方(像聯集可能會把故意移除的值加回來),就印出警告讓人看得到。
  • 用真實發生過的案例寫測試。 上面每個合併的問題,我們都把當時實際出問題的那段規格寫成測試,而且確認在修正之前是紅的。這樣之後有人改合併邏輯,才不會不小心又改回去。
  • 共用工具就是要讓大家一起改。 這個工具是我起頭的,但很多重要的修正都是同事在自己的專案裡遇到問題後補上的。把腳本從各專案抽成一個套件最大的好處,就是一個人修好,9 個專案一起受惠。

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言